/* * Licensed to Jasig under one or more contributor license * agreements. See the NOTICE file distributed with this work * for additional information regarding copyright ownership. * Jasig licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file * except in compliance with the License. You may obtain a * copy of the License at the following location: * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package io.cos.cas.adaptors.postgres.handlers; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.security.MessageDigest; import java.util.HashMap; import java.util.Map; import io.cos.cas.adaptors.postgres.models.OpenScienceFrameworkGuid; import io.cos.cas.adaptors.postgres.models.OpenScienceFrameworkTimeBasedOneTimePassword; import io.cos.cas.adaptors.postgres.models.OpenScienceFrameworkUser; import io.cos.cas.adaptors.postgres.daos.OpenScienceFrameworkDaoImpl; import io.cos.cas.authentication.LoginNotAllowedException; import io.cos.cas.authentication.OneTimePasswordFailedLoginException; import io.cos.cas.authentication.OneTimePasswordRequiredException; import io.cos.cas.authentication.OpenScienceFrameworkCredential; import io.cos.cas.authentication.ShouldNotHappenException; import io.cos.cas.authentication.oath.TotpUtils; import org.jasig.cas.authentication.AccountDisabledException; import org.jasig.cas.authentication.Credential; import org.jasig.cas.authentication.HandlerResult; import org.jasig.cas.authentication.PreventedException; import org.jasig.cas.authentication.handler.NoOpPrincipalNameTransformer; import org.jasig.cas.authentication.handler.PrincipalNameTransformer; import org.jasig.cas.authentication.handler.support.AbstractPreAndPostProcessingAuthenticationHandler; import org.springframework.beans.factory.InitializingBean; import org.springframework.security.crypto.bcrypt.BCrypt; import javax.security.auth.login.AccountNotFoundException; import javax.security.auth.login.FailedLoginException; import javax.validation.constraints.NotNull; /** * The Open Science Framework Authentication handler. * * @author Michael Haselton * @author Longze Chen * @since 4.1.0 */ public class OpenScienceFrameworkAuthenticationHandler extends AbstractPreAndPostProcessingAuthenticationHandler implements InitializingBean { // time-based one time password parameters private static final int TOTP_INTERVAL = 30; private static final int TOTP_WINDOW = 1; // user status private static final String USER_ACTIVE = "ACTIVE"; private static final String USER_NOT_CONFIRMED = "NOT_CONFIRMED"; private static final String USER_NOT_CLAIMED = "NOT_CLAIMED"; private static final String USER_MERGED = "MERGED"; private static final String USER_DISABLED = "DISABLED"; private static final String USER_STATUS_UNKNOWN = "UNKNOWN"; @NotNull private PrincipalNameTransformer principalNameTransformer = new NoOpPrincipalNameTransformer(); @NotNull private OpenScienceFrameworkDaoImpl openScienceFrameworkDao; /** Default Constructor. */ public OpenScienceFrameworkAuthenticationHandler() {} /** * @param principalNameTransformer the principal name transformer. */ public void setPrincipalNameTransformer(final PrincipalNameTransformer principalNameTransformer) { this.principalNameTransformer = principalNameTransformer; } /** * @param openScienceFrameworkDao the open science framework data access object */ public void setOpenScienceFrameworkDao(final OpenScienceFrameworkDaoImpl openScienceFrameworkDao) { this.openScienceFrameworkDao = openScienceFrameworkDao; } @Override public void afterPropertiesSet() throws Exception {} @Override protected final HandlerResult doAuthentication(final Credential credential) throws GeneralSecurityException, PreventedException { final OpenScienceFrameworkCredential osfCredential = (OpenScienceFrameworkCredential) credential; if (osfCredential.getUsername() == null) { throw new AccountNotFoundException("Username is null."); } final String transformedUsername = principalNameTransformer.transform(osfCredential.getUsername()); if (transformedUsername == null) { throw new AccountNotFoundException("Transformed username is null."); } osfCredential.setUsername(transformedUsername); return authenticateInternal(osfCredential); } /** * Authenticates an Open Science Framework credential. * * @param credential the credential object bearing the username, password, etc... * * @return HandlerResult resolved from credential on authentication success or null if no principal could be resolved * from the credential. * * @throws GeneralSecurityException On authentication failure. * @throws PreventedException On the indeterminate case when authentication is prevented. */ protected final HandlerResult authenticateInternal(final OpenScienceFrameworkCredential credential) throws GeneralSecurityException, PreventedException { final String username = credential.getUsername().toLowerCase(); final String plainTextPassword = credential.getPassword(); final String verificationKey = credential.getVerificationKey(); final String oneTimePassword = credential.getOneTimePassword(); final OpenScienceFrameworkUser user = openScienceFrameworkDao.findOneUserByEmail(username); if (user == null) { throw new AccountNotFoundException(username + " not found with query"); } Boolean validPassphrase = Boolean.FALSE; final String userStatus = verifyUserStatus(user); if (credential.isRemotePrincipal()) { // verified through remote principals validPassphrase = Boolean.TRUE; } else if (verificationKey != null && verificationKey.equals(user.getVerificationKey())) { // verified by verification key validPassphrase = Boolean.TRUE; } else if (plainTextPassword != null && verifyPassword(plainTextPassword, user.getPassword())) { // verified by password validPassphrase = Boolean.TRUE; } if (!validPassphrase) { throw new FailedLoginException(username + ": invalid remote authentication, verification key or password"); } final OpenScienceFrameworkTimeBasedOneTimePassword timeBasedOneTimePassword = openScienceFrameworkDao.findOneTimeBasedOneTimePasswordByOwnerId(user.getId()); // if the user has set up two factors authentication if (timeBasedOneTimePassword != null && timeBasedOneTimePassword.getTotpSecret() != null && timeBasedOneTimePassword.isConfirmed() && !timeBasedOneTimePassword.isDeleted()) { // if no one time password is provided in credential, redirect to `casOtpLoginView` if (oneTimePassword == null) { throw new OneTimePasswordRequiredException("Time-based One Time Password required"); } // verify one time password try { final Long longOneTimePassword = Long.valueOf(oneTimePassword); if (!TotpUtils.checkCode(timeBasedOneTimePassword.getTotpSecretBase32(), longOneTimePassword, TOTP_INTERVAL, TOTP_WINDOW)) { throw new OneTimePasswordFailedLoginException(username + " invalid time-based one time password"); } } catch (final Exception e) { throw new OneTimePasswordFailedLoginException(username + ": invalid time-based one time password"); } } // Check user's status, and only ACTIVE user can sign in if (USER_NOT_CONFIRMED.equals(userStatus)) { throw new LoginNotAllowedException(username + " is not registered"); } else if (USER_DISABLED.equals(userStatus)) { throw new AccountDisabledException(username + " is disabled"); } else if (USER_NOT_CLAIMED.equals(userStatus)) { throw new ShouldNotHappenException(username + " is not claimed"); } else if (USER_MERGED.equals(userStatus)) { throw new ShouldNotHappenException("Cannot log in to a merged user " + username); } else if (USER_STATUS_UNKNOWN.equals(userStatus)) { throw new ShouldNotHappenException(username + " is not active: unknown status"); } final Map<String, Object> attributes = new HashMap<>(); attributes.put("username", user.getUsername()); attributes.put("givenName", user.getGivenName()); attributes.put("familyName", user.getFamilyName()); // CAS returns the user's GUID to OSF // Note: GUID is recommended. Do not use user's pimary key or username. final OpenScienceFrameworkGuid guid = openScienceFrameworkDao.findGuidByUser(user); return createHandlerResult(credential, this.principalFactory.createPrincipal(guid.getGuid(), attributes), null); } /** * {@inheritDoc} * @return True if credential is a {@link OpenScienceFrameworkCredential}, false otherwise. */ @Override public boolean supports(final Credential credential) { return credential instanceof OpenScienceFrameworkCredential; } /** * Verify User Status. * * USER_ACTIVE: * authentication succeed * USER_NOT_CONFIRMED: * inform the user that the account is not confirmed and provide a resend confirmation link * USER_DISABLED: * inform the user that the account is disable and that they can contact OSF support * USER_MERGED, USER_NOT_CLAIMED and USER_STATUS_UNKNOWN: * these is not suppose to happen, ask user to contact OSF support * * @param user the OSF user * @return the user status */ private String verifyUserStatus(final OpenScienceFrameworkUser user) { // An active user must be registered, claimed, not disabled, not merged and has a not null/None password. // Only active user can pass the verification. if (user.isActive()) { logger.info("User Status Check: {}", USER_ACTIVE); return USER_ACTIVE; } else { // If the user instance is not claimed, it is also not registered and not confirmed. // It can be either an unclaimed contributor or a new user pending confirmation. if (!user.isClaimed() && !user.isRegistered() && !user.isConfirmed()) { if (isUnusablePassword(user.getPassword())) { // If the user instance has an unusable password, it must be an unclaimed contributor. logger.info("User Status Check: {}", USER_NOT_CLAIMED); return USER_NOT_CLAIMED; } else if (checkPasswordPrefix(user.getPassword())) { // If the user instance has a password with a valid prefix, it must be a unconfirmed user who // has registered for a new account. logger.info("User Status Check: {}", USER_NOT_CONFIRMED); return USER_NOT_CONFIRMED; } } // If the user instance is merged by another user, it is registered, confirmed and claimed. // `.merged_by` field being not null is a sufficient condition. // However, its username is set to GUID and password is set to unusable. if (user.isMerged()) { logger.info("User Status Check: {}", USER_MERGED); return USER_MERGED; } // If the user instance is disabled, it is also not registered but claimed. // `.date_disabled` field being not null is a sufficient condition. // However, it still has the username and password. // When the user tries to login, an account disabled message will be displayed. if (user.isDisabled()) { logger.info("User Status Check: {}", USER_DISABLED); return USER_DISABLED; } // Other status combinations are considered UNKNOWN logger.info("User Status Check: {}", USER_STATUS_UNKNOWN); return USER_STATUS_UNKNOWN; } } /** * Verify Password. `bcrypt$` (backward compatibility) and `bcrypt_sha256$` are the only two valid prefix. * * @param plainTextPassword the plain text password provided by the user * @param userPasswordHash the password hash stored in database * @return True if verified, False otherwise */ private boolean verifyPassword(final String plainTextPassword, final String userPasswordHash) { String password, passwordHash; try { if (userPasswordHash.startsWith("bcrypt$")) { // django.contrib.auth.hashers.BCryptPasswordHasher passwordHash = userPasswordHash.split("bcrypt\\$")[1]; password = plainTextPassword; } else if(userPasswordHash.startsWith("bcrypt_sha256$")) { // django.contrib.auth.hashers.BCryptSHA256PasswordHasher passwordHash = userPasswordHash.split("bcrypt_sha256\\$")[1]; password = sha256HashPassword(plainTextPassword); } else { // invalid password hash prefix return false; } passwordHash = updateBCryptHashIdentifier(passwordHash); return password != null && passwordHash != null && BCrypt.checkpw(password, passwordHash); } catch (final Exception e) { // Do not log stack trace which may contain user's plaintext password logger.error(String.format("CAS has encountered a problem when verifying the password: %s.", e.toString())); return false; } } /** * Check if the password hash is "django-unusable". * * @param passwordHash the password hash * @return true if unusable, false otherwise */ private boolean isUnusablePassword(final String passwordHash) { return passwordHash == null || passwordHash.startsWith("!"); } /** * Check if the password hash bears a valid prefix. * * @param passwordHash the password hash * @return true if usable, false otherwise */ private boolean checkPasswordPrefix(final String passwordHash) { return passwordHash != null && (passwordHash.startsWith("bcrypt$") || passwordHash.startsWith("bcrypt_sha256$")); } /** * Hash the password using SHA256, the first step for BCryptSHA256. * This is dependent on django.contrib.auth.hashers.BCryptSHA256PasswordHasher. * * @param password the plain text password provided by user * @return the password hash in String or null */ private String sha256HashPassword(final String password) { try { final MessageDigest digest = MessageDigest.getInstance("SHA-256"); final byte[] sha256HashedPassword = digest.digest(password.getBytes(StandardCharsets.UTF_8)); final StringBuilder builder = new StringBuilder(); for (final byte b : sha256HashedPassword) { builder.append(String.format("%02x", b)); } return builder.toString(); } catch (final Exception e) { // Do not log stack trace which may contain user's plaintext password logger.error(String.format("CAS has encountered a problem when sha256-hashing the password: %s.", e.toString())); return null; } } /** * Update BCrypt Hash Identifier for Compatibility. * * Spring's BCrypt implements the specification and is not vulnerable to OpenBSD's `u_int8_t` overflow issue. How- * ever, it only recognizes `$2$` or `$2a$` identifier for a password BCrypt hash. The solution is to replace `$2b$` * or `$2y$` with `$2a` in the hash before calling `BCrypt.checkpw()`. This is correct and secure. * * @param passwordHash the password hash by BCrypt or BCryptSHA256 * @return the spring compatible hash string or null */ private String updateBCryptHashIdentifier(final String passwordHash) { try { if (passwordHash.charAt(2) != '$') { final StringBuilder builder = new StringBuilder(passwordHash); builder.setCharAt(2, 'a'); return builder.toString(); } return passwordHash; } catch (final Exception e) { // Do not log stack trace which may contain user's plaintext password logger.error(String.format("CAS has encountered a problem when updating password hash identifier: %s.", e.toString())); return null; } } }